Skip to content

feat(fcm): Migrate topic management to FCM v1 API - #1240

Merged
lahirumaramba merged 5 commits into
mainfrom
lm-fcm-topics
Sep 24, 2026
Merged

lahirumaramba merged 5 commits into
mainfrom
lm-fcm-topics

Conversation

@lahirumaramba

@lahirumaramba lahirumaramba commented Sep 17, 2026 •

Copy link
Copy Markdown
Member

Migrates topic subscription and management from the legacy Instance ID (IID) service (iid.googleapis.com) to the FCM v1 REST API (fcm.googleapis.com/v1/projects/{projectId}/registrations/{token}/topicSubscriptions).

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request migrates topic subscription and unsubscription operations in FirebaseMessaging from the legacy Instance ID API to the FCM v1 API, while retaining the legacy methods as deprecated. The new implementation in FirebaseMessagingClientImpl handles these operations concurrently using CompletableFuture. However, the current design creates and shuts down a new thread pool for every topic management request, which is highly inefficient and can cause thread exhaustion under high load. It is recommended to initialize a single shared ExecutorService in the constructor with core thread timeout enabled, and reuse it across requests to improve performance and simplify the request handling logic.

Comment thread src/main/java/com/google/firebase/messaging/FirebaseMessagingClientImpl.java Outdated
Comment thread src/main/java/com/google/firebase/messaging/FirebaseMessagingClientImpl.java Outdated
@lahirumaramba

Copy link
Copy Markdown
Member Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request migrates topic subscription and unsubscription operations to the FCM v1 API, introducing new asynchronous methods in FirebaseMessagingClientImpl using CompletableFuture and deprecating the legacy Instance ID API methods. The feedback recommends using daemon threads in the default executor to prevent JVM shutdown delays in short-lived applications, and wrapping task submissions in a try-catch block to gracefully handle RejectedExecutionException when custom bounded executors are used.

Comment thread src/main/java/com/google/firebase/messaging/FirebaseMessagingClientImpl.java Outdated
Comment thread src/main/java/com/google/firebase/messaging/FirebaseMessagingClientImpl.java Outdated
@lahirumaramba

Copy link
Copy Markdown
Member Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request migrates topic management operations (subscribe/unsubscribe) from the legacy Instance ID API to the FCM v1 API. It introduces a configurable FCM host, adds a dedicated executor service for concurrent topic management requests, and updates the internal client implementation and corresponding tests to support these changes. The review identified a potential issue where a trailing slash in a custom FCM host configuration could lead to malformed URLs, and a code suggestion was provided to normalize the host string.

Comment on lines +98 to +100
this.projectId = builder.projectId;
this.fcmHost = Strings.isNullOrEmpty(builder.fcmHost) ? DEFAULT_FCM_HOST : builder.fcmHost;
this.fcmSendUrl = String.format(FCM_URL, this.fcmHost, builder.projectId);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If builder.fcmHost is configured with a trailing slash (e.g., https://custom.host.com/), it can result in URLs with double slashes (e.g., https://custom.host.com//v1/projects/...). This can cause unexpected 404 errors or authentication failures with some strict API gateways or proxies. Consider normalizing fcmHost by stripping any trailing slash.

Suggested change
this.projectId = builder.projectId;
this.fcmHost = Strings.isNullOrEmpty(builder.fcmHost) ? DEFAULT_FCM_HOST : builder.fcmHost;
this.fcmSendUrl = String.format(FCM_URL, this.fcmHost, builder.projectId);
this.projectId = builder.projectId;
String host = Strings.isNullOrEmpty(builder.fcmHost) ? DEFAULT_FCM_HOST : builder.fcmHost;
if (host.endsWith("/")) {
host = host.substring(0, host.length() - 1);
}
this.fcmHost = host;
this.fcmSendUrl = String.format(FCM_URL, this.fcmHost, builder.projectId);

@jonathanedey jonathanedey left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, but with some push back on the new thread pool.

Comment on lines +109 to +129
this.executor = builder.executor != null
? builder.executor
: createDefaultExecutor(builder.threadFactory);
this.threadFactory = builder.threadFactory;
}

private static ExecutorService createDefaultExecutor(ThreadFactory threadFactory) {
ThreadFactory baseFactory =
threadFactory != null ? threadFactory : Executors.defaultThreadFactory();
ThreadFactory factory = new ThreadFactoryBuilder()
.setThreadFactory(baseFactory)
.setNameFormat("firebase-messaging-topics-%d")
.setDaemon(true)
.build();
ThreadPoolExecutor pool = new ThreadPoolExecutor(
100, 100,
60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<Runnable>(),
factory);
pool.allowCoreThreadTimeOut(true);
return pool;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm hesitant on adding a nested thread executor here. The existing implementation already uses an app level thread manager which developers can configure through options. Adding another layer of threads without developer control could have be a negative side effect of more thread overhead than the developer is expecting.

Comment on lines +252 to +277
List<CompletableFuture<TopicResult>> futures = new ArrayList<>();
for (int i = 0; i < registrationTokens.size(); i++) {
final int index = i;
final String token = registrationTokens.get(i);
try {
futures.add(CompletableFuture.supplyAsync(
() -> sendSingleTopicRequest(token, topicName, isSubscribe, index),
this.executor));
} catch (RejectedExecutionException e) {
futures.add(CompletableFuture.completedFuture(
TopicResult.error(index, "REJECTED_BY_EXECUTOR")));
}
}

CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();

int successCount = 0;
List<TopicManagementResponse.Error> errors = new ArrayList<>();
for (CompletableFuture<TopicResult> future : futures) {
TopicResult result = future.join();
if (result.isSuccess()) {
successCount++;
} else {
errors.add(new TopicManagementResponse.Error(result.getIndex(), result.getReason()));
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we solved a similar issue without a new thread pool with sendEachAsync. Are there advantages to this method over the sendEachAsync way?

// Gather all futures and combine into a list
ApiFuture<List<SendResponse>> responsesFuture = ApiFutures.allAsList(list);
// Chain this future to wrap the eventual responses in a BatchResponse without blocking
// the main thread. This uses the current thread to execute, but since the transformation
// function is non-blocking the transformation itself is also non-blocking.
return ApiFutures.transform(
responsesFuture,
(responses) -> {
return new BatchResponseImpl(responses);
},
MoreExecutors.directExecutor());

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for point this here and in the above comment. I will change it to a similar method we used in sendEach

Comment on lines +324 to +369
private String extractReason(FirebaseMessagingException e) {
if (e.getMessagingErrorCode() != null) {
return e.getMessagingErrorCode().name();
}
if (e.getHttpResponse() != null && !Strings.isNullOrEmpty(e.getHttpResponse().getContent())) {
try {
MessagingServiceErrorResponse parsed = jsonFactory.createJsonParser(
e.getHttpResponse().getContent())
.parseAndClose(MessagingServiceErrorResponse.class);
if (parsed.getMessagingErrorCode() != null) {
return parsed.getMessagingErrorCode().name();
}
if (!Strings.isNullOrEmpty(parsed.getStatus())) {
return parsed.getStatus();
}
if (!Strings.isNullOrEmpty(parsed.getErrorMessage())) {
return parsed.getErrorMessage();
}
} catch (Exception ignore) {
// Ignore JSON parsing errors
}
}
if (e.getErrorCode() != null && e.getErrorCode() != ErrorCode.UNKNOWN) {
return e.getErrorCode().name();
}
if (e.getHttpResponse() != null) {
switch (e.getHttpResponse().getStatusCode()) {
case 400:
return "INVALID_ARGUMENT";
case 401:
case 403:
return "PERMISSION_DENIED";
case 404:
return "NOT_FOUND";
case 429:
return "RESOURCE_EXHAUSTED";
case 500:
return "INTERNAL";
case 503:
return "DEADLINE_EXCEEDED";
default:
return "UNKNOWN_ERROR";
}
}
return "UNKNOWN_ERROR";
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as others languages. Not blocking but I don't think we need to reparse the firebase error. We can keep the custom mapping if nessasary for matching the status codes error codes correctly.

case 500:
return "INTERNAL";
case 503:
return "DEADLINE_EXCEEDED";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is 503 "Service Unavailable" and 504 "DEADLINE_EXCEEDED"?

@lahirumaramba lahirumaramba added the release:stage Stage a release candidate label Sep 24, 2026
…r topic management

- Delegate topic subscriptions asynchronously per token using CallableOperation.callAsync(app) in FirebaseMessaging, matching sendEachAsync pattern and respecting developer-configured ThreadManager
- Remove internal ExecutorService and ThreadFactory configuration from FirebaseMessagingClientImpl
- Simplify extractReason to rely on MessagingErrorHandler populating getMessagingErrorCode() without reparsing raw JSON
- Map HTTP 503 to UNAVAILABLE and HTTP 504 to DEADLINE_EXCEEDED in extractReason
- Normalize fcmHost during construction by stripping trailing slashes
- Update unit tests in FirebaseMessagingTest and FirebaseMessagingClientImplTest

@jonathanedey jonathanedey left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, Thanks!

@lahirumaramba
lahirumaramba merged commit 4124d03 into main Sep 24, 2026
17 checks passed
@lahirumaramba
lahirumaramba deleted the lm-fcm-topics branch September 24, 2026 19:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release:stage Stage a release candidate release-note

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants